오프라인 큐에 멱등성이 필요한 이유
오프라인 큐에 멱등성이 필요한 이유
모바일 오프라인 큐는 요청을 정확히 한 번 전달할 수 없다. 서버가 처리한 직후 응답을 잃거나, 전송 중 앱이 종료되거나, worker가 중복 실행되면 같은 작업이 다시 전송된다. 사용자 의도를 만든 순간 안정적인 operationId를 발급하고 모든 재시도에 재사용한다. 서버는 사용자 범위의 operation ID와 요청 fingerprint를 저장해 같은 의도에는 같은 결과를 반환한다. 로컬 큐는 pending → sending → done뿐 아니라 lease 만료 복구와 영구 실패를 가진 상태 기계로 관리한다.
목차
- #응답을 받지 못했다고 서버가 실패한 것은 아니다
- #Exactly Once 대신 At Least Once와 멱등한 효과
- #사용자 의도와 전송 시도를 구분하기
- #Operation ID는 최초 생성 시점에 고정하기
- #로컬 변경과 큐 삽입을 한 Transaction으로 묶기
- #큐 항목을 상태 기계로 관리하기
- #Lease로 중단된 Sending 상태 복구하기
- #서버에 Idempotency Record 저장하기
- #동시에 같은 Operation이 도착하는 경우
- #같은 Key에 다른 Payload가 오면 거절하기
- #재시도 가능한 실패와 영구 실패를 나누기
- #순서가 있는 작업과 없는 작업 구분하기
- #낙관적 UI와 서버 결과를 합치기
- #Logout과 계정 전환에서 큐 격리하기
- #테스트해야 할 실패 시점
- #운영 지표와 정리 정책
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
응답을 받지 못했다고 서버가 실패한 것은 아니다
사용자가 오프라인 상태에서 기록 하나를 추가했다고 하자. 앱은 로컬에 먼저 보여 주고 연결이 돌아오면 서버로 보낸다.
{
"type": "CREATE_ENTRY",
"payload": {
"value": 120,
"recordedAt": "2026-01-13T09:10:00+09:00"
}
}
서버 요청에서 가장 곤란한 순간은 요청이 실패한 때가 아니라 결과를 모르는 때다.
sequenceDiagram
participant App
participant API
participant DB
App->>API: CREATE_ENTRY
API->>DB: row insert
DB-->>API: commit success
API--xApp: 응답 전달 중 연결 끊김
Note over App: timeout, 성공 여부를 모름
App->>API: 같은 CREATE_ENTRY 재전송
API->>DB: 두 번째 row insert앱에는 timeout으로 보이지만 첫 번째 insert는 이미 commit됐다. 단순 재시도는 기록을 두 개 만든다. 결제, 포인트 지급, 알림 발송이라면 영향은 더 크다.
연결 상태를 확인한 뒤 한 번만 보내도 해결되지 않는다. Wi-Fi가 연결됐다는 사실은 요청 전체 왕복을 보장하지 않고, 전송 중 언제든 앱 process가 종료될 수 있다. 자세한 네트워크 판정 문제는 앱의 네트워크 상태를 신뢰하면 안 되는 이유에서 다룬다.
Client는 응답을 받지 못했을 때 “서버가 실행하지 않았다”와 “실행했지만 응답만 잃었다”를 구분할 수 없다.
이 글의 식별자와 도메인은 실제 프로젝트 코드가 아닌 가상 기록 앱을 위한 예시다.
Exactly Once 대신 At Least Once와 멱등한 효과
분산된 두 실행 환경에서 정확히 한 번 전달을 약속하기는 어렵다. 대신 전달은 적어도 한 번 일어날 수 있게 재시도하고, 여러 번 전달돼도 업무 효과는 한 번만 발생하게 만든다.
전달 보장: at least once
업무 효과: idempotent
관찰 결과: 사용자는 한 번 실행된 것으로 봄
멱등성은 HTTP method 이름만으로 자동으로 생기지 않는다. PUT /entries/42가 같은 최종 상태를 만든다면 자연스럽게 멱등할 수 있지만, 외부 알림이나 audit event까지 매번 발행하면 전체 업무 효과는 멱등하지 않을 수 있다.
flowchart LR
A[동일 operation 재전송] --> B[API idempotency gate]
B -->|처음| C[Domain transaction]
C --> D[결과 저장]
B -->|중복| E[저장된 결과 반환]
D --> F[Client 완료]
E --> FAPI response만 같게 만드는 것이 아니라 DB 변경, event 발행, 외부 API 호출까지 어느 범위를 한 번으로 볼지 정해야 한다.
사용자 의도와 전송 시도를 구분하기
사용자가 저장 버튼을 한 번 누른 것이 operation이고, worker가 network로 보내는 각 시도가 attempt다.
Operation
operationId = op_demo_7
createdAt = 09:10:00
payload = value 120
Attempt 1
startedAt = 09:10:03
result = timeout
Attempt 2
startedAt = 09:11:10
result = 200 replayed
attempt마다 새 operation ID를 만들면 서버는 두 요청이 같은 사용자 의도인지 알 수 없다.
// 잘못된 방식: 재시도마다 새 ID
Future<void> sendWithRetry(CreateEntry payload) async {
for (var attempt = 0; attempt < 3; attempt++) {
await api.create(
operationId: uuid.v4(),
payload: payload,
);
}
}
ID는 큐 항목을 만들 때 한 번 생성한다.
final operation = OfflineOperation(
operationId: uuid.v4(),
accountId: currentAccount.id,
type: OfflineOperationType.createEntry,
payloadVersion: 1,
payload: CreateEntryPayload(
localEntryId: localEntryId,
value: 120,
recordedAt: clock.now(),
),
createdAt: clock.now(),
);
worker는 몇 번 재시도하더라도 operation.operationId를 그대로 보낸다.
Operation ID는 최초 생성 시점에 고정하기
좋은 operation ID는 다음 성질을 가진다.
- 같은 사용자 의도의 모든 attempt에서 동일하다.
- 다른 의도와 충돌하기 어렵다.
- 앱 재시작 후에도 queue row에 남는다.
- 서버에서 인증된 사용자 범위와 함께 unique하다.
- payload의 비밀이나 사용자 정보를 인코딩하지 않는다.
device-7:entry:1042처럼 기기 ID와 local counter를 조합할 수도 있지만 reinstall, backup 복원, 여러 기기에서 충돌 정책이 필요하다. 충분한 무작위 UUID를 생성하고 별도 컬럼에 account와 entity를 보관하는 편이 단순하다.
POST /v1/entries HTTP/1.1
Authorization: Bearer access-token
Idempotency-Key: 8b62d456-demo-4d4e
Content-Type: application/json
{
"schemaVersion": 1,
"clientEntryId": "entry-local-demo-12",
"value": 120,
"recordedAt": "2026-01-13T00:10:00Z"
}
operation ID를 URL이나 로그에 사용자 식별 정보와 함께 노출하지 않는다. 서버 로그에는 필요하면 축약하거나 hash한 correlation 값을 사용한다.
로컬 변경과 큐 삽입을 한 Transaction으로 묶기
offline-first UI는 로컬 entity를 먼저 저장하고 화면에 보여 준다. entity 저장과 queue 삽입이 서로 다른 transaction이면 한쪽만 성공할 수 있다.
sequenceDiagram
participant UI
participant LocalDB
UI->>LocalDB: entry 저장
LocalDB-->>UI: commit
Note over UI: queue 저장 전 앱 종료
Note over LocalDB: 화면에는 있지만 서버로 보낼 작업 없음두 쓰기를 같은 local DB transaction으로 묶는다.
BEGIN;
INSERT INTO entries (
local_id,
value,
recorded_at,
sync_state
) VALUES (
:local_id,
:value,
:recorded_at,
'pending'
);
INSERT INTO offline_operations (
operation_id,
account_id,
operation_type,
payload_version,
payload_json,
state,
attempt_count,
next_attempt_at,
created_at
) VALUES (
:operation_id,
:account_id,
'create_entry',
1,
:payload_json,
'pending',
0,
:now,
:now
);
COMMIT;
SQLite를 사용한다면 실제 schema와 transaction API에 맞게 구현한다. 핵심 invariant는 “로컬에 pending entity가 보이면 이를 서버로 보낼 operation이 반드시 있다”는 것이다.
큐 항목을 상태 기계로 관리하기
boolean isSynced 하나로는 전송 중, 재시도 대기, 영구 실패를 표현하기 어렵다.
stateDiagram-v2
[*] --> Pending
Pending --> Sending: lease 획득
Sending --> Done: 성공 또는 replay
Sending --> RetryWaiting: 일시 실패
RetryWaiting --> Pending: nextAttemptAt 도달
Sending --> PermanentFailed: 영구 실패
Sending --> Pending: lease 만료 복구
PermanentFailed --> Pending: 사용자 수정 후 새 시도
Done --> [*]예시 schema:
CREATE TABLE offline_operations (
operation_id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
operation_type TEXT NOT NULL,
payload_version INTEGER NOT NULL,
payload_json TEXT NOT NULL,
payload_fingerprint TEXT NOT NULL,
state TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL,
lease_owner TEXT,
lease_expires_at INTEGER,
last_error_code TEXT,
created_at INTEGER NOT NULL,
completed_at INTEGER
);
CREATE INDEX idx_offline_operations_ready
ON offline_operations (
account_id,
state,
next_attempt_at,
created_at
);
done row를 바로 지울지 잠시 보관할지는 UI reconciliation과 진단 요구에 따라 정한다. 무기한 쌓이지 않게 retention이 필요하다.
Lease로 중단된 Sending 상태 복구하기
worker가 row를 sending으로 바꾼 직후 앱이 종료되면 그 row를 영원히 건너뛰어서는 안 된다. 전송 소유권에 만료 시간을 둔다.
UPDATE offline_operations
SET
state = 'sending',
lease_owner = :worker_id,
lease_expires_at = :lease_expires_at,
attempt_count = attempt_count + 1
WHERE operation_id = (
SELECT operation_id
FROM offline_operations
WHERE account_id = :account_id
AND (
state = 'pending'
OR (
state = 'sending'
AND lease_expires_at < :now
)
)
AND next_attempt_at <= :now
ORDER BY created_at
LIMIT 1
)
RETURNING *;
실제 SQLite version에서 RETURNING 지원 여부와 동시 transaction 동작을 확인한다. 지원하지 않으면 select와 conditional update를 transaction으로 묶고 변경 row 수를 확인한다.
lease는 network timeout보다 충분히 길되 영구 lock이 되지 않게 한다. lease가 만료됐다고 첫 worker가 실제로 중단됐다는 보장은 없다. 두 worker가 잠시 겹칠 수 있으므로 서버 멱등성이 여전히 필요하다.
앱 내부 mutex는 process 종료 후 사라지고, background worker와 foreground sync가 다른 lifecycle에서 겹칠 수 있다. lease는 복구를 돕지만 중복 전달 가능성을 제거하지 않는다.
서버에 Idempotency Record 저장하기
서버는 인증된 actor와 operation ID를 unique key로 사용한다.
CREATE TABLE idempotency_records (
actor_id UUID NOT NULL,
operation_id UUID NOT NULL,
operation_type VARCHAR(80) NOT NULL,
request_fingerprint CHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL,
response_status INTEGER,
response_body JSONB,
resource_id UUID,
created_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (actor_id, operation_id)
);
actor 범위를 넣지 않으면 서로 다른 사용자가 우연히 같은 UUID를 보냈을 때 결과가 섞일 수 있다. 반대로 전역 unique operation ID 정책을 사용할 수도 있지만 인증 주체와 요청 권한 검증은 별도로 해야 한다.
처리 흐름:
async function createEntry(
actor: Actor,
request: CreateEntryRequest,
operationId: string,
): Promise<ApiResponse> {
const fingerprint = canonicalFingerprint(request);
return database.transaction(async (tx) => {
const reservation = await idempotency.reserve(tx, {
actorId: actor.id,
operationId,
operationType: "CREATE_ENTRY",
fingerprint,
});
if (reservation.kind === "completed") {
return reservation.savedResponse;
}
if (reservation.kind === "payload_conflict") {
throw new ConflictError(
"IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD",
);
}
const entry = await entries.insert(tx, {
actorId: actor.id,
clientEntryId: request.clientEntryId,
value: request.value,
recordedAt: request.recordedAt,
});
const response = {
status: 201,
body: {
entryId: entry.id,
clientEntryId: request.clientEntryId,
},
};
await idempotency.complete(tx, reservation, response);
return response;
});
}
domain write와 idempotency 완료를 같은 transaction에 넣으면 둘 중 하나만 commit되는 상태를 줄일 수 있다. 외부 결제 API처럼 같은 DB transaction에 넣을 수 없는 부수효과는 해당 서비스의 idempotency key와 outbox·상태 기계를 함께 사용해야 한다.
동시에 같은 Operation이 도착하는 경우
두 attempt가 동시에 서버에 도착할 수 있다. “먼저 조회하고 없으면 insert”만 하면 race가 발생한다.
sequenceDiagram
participant A as Request A
participant B as Request B
participant DB
A->>DB: record 없음 확인
B->>DB: record 없음 확인
A->>DB: domain insert
B->>DB: domain insertunique constraint와 atomic reservation이 필요하다.
INSERT INTO idempotency_records (
actor_id,
operation_id,
operation_type,
request_fingerprint,
status,
created_at,
expires_at
) VALUES (
:actor_id,
:operation_id,
:operation_type,
:fingerprint,
'processing',
NOW(),
NOW() + INTERVAL '7 days'
)
ON CONFLICT (actor_id, operation_id) DO NOTHING;
insert한 요청만 domain operation을 수행한다. 기존 row가 completed면 저장된 결과를 반환한다. processing이면 짧게 기다릴지, 409/202와 retry hint를 반환할지 정한다. processing row가 worker crash로 영원히 남지 않게 server-side lease나 transaction rollback 구조도 필요하다.
같은 Key에 다른 Payload가 오면 거절하기
operation ID만 보고 기존 response를 반환하면 client bug가 숨겨질 수 있다.
op-42, value=120 → 201 Created
op-42, value=999 → 이전 201 반환?
정규화한 업무 필드의 fingerprint를 저장하고 같은 key에 다른 payload가 오면 conflict로 거절한다.
function canonicalFingerprint(
request: CreateEntryRequest,
): string {
return sha256(
canonicalJson({
clientEntryId: request.clientEntryId,
value: request.value,
recordedAt: normalizeInstant(request.recordedAt),
}),
);
}
request timestamp, trace ID처럼 attempt마다 달라지는 metadata는 fingerprint에서 제외한다. 반대로 업무 의미를 바꾸는 필드는 반드시 포함한다. 원본 payload 자체가 민감하면 idempotency table에 전체를 복사하지 않고 fingerprint와 필요한 response만 저장한다.
재시도 가능한 실패와 영구 실패를 나누기
모든 실패를 재시도하면 잘못된 payload가 배터리와 network를 계속 소비한다.
| 결과 | 큐 처리 |
|---|---|
| network timeout·연결 끊김 | retry waiting |
| 408·429 | Retry-After와 backoff 고려 |
| 5xx | 제한된 지수 backoff |
| 401 | session 갱신 한 번 후 재평가 |
| 403 | permanent failed, 권한 변경 안내 |
| 400·422 | permanent failed, 사용자 수정 필요 |
| idempotency replay 성공 | done |
| 같은 key·다른 payload conflict | 구현 오류 또는 permanent failed |
SyncDecision classify(SyncFailure failure) {
return switch (failure) {
NetworkUnavailable() => const SyncDecision.retry(),
TimeoutFailure() => const SyncDecision.retry(),
RateLimited(:final retryAfter) =>
SyncDecision.retry(after: retryAfter),
ServerFailure() => const SyncDecision.retry(),
Unauthorized() => const SyncDecision.refreshSessionOnce(),
Forbidden() => const SyncDecision.failPermanently(),
ValidationFailure() => const SyncDecision.needsUserAction(),
PayloadConflict() => const SyncDecision.failPermanently(),
};
}
재시도에는 최대 횟수 하나만 두기보다 elapsed time, 오류 종류, 사용자 행동을 함께 본다. jitter가 있는 backoff는 재시도에 지수 백오프와 지터가 필요한 이유와 연결된다.
순서가 있는 작업과 없는 작업 구분하기
같은 entity에 create, update, delete가 쌓이면 무작정 병렬 전송할 수 없다.
op-1 CREATE local-entry-7
op-2 UPDATE local-entry-7 value=130
op-3 DELETE local-entry-7
선택지는 다음과 같다.
- entity별 순서를 보존해 직렬 전송
- 아직 전송 전이면 create payload에 update를 합침
- create 전 delete면 두 operation과 local entity를 함께 제거
- dependency ID로 op-2가 op-1 완료 뒤 실행되게 함
서로 다른 entity의 독립 작업은 제한된 concurrency로 병렬화할 수 있다. 전역 FIFO 하나는 단순하지만 한 실패가 전체 queue를 막는 head-of-line blocking을 만든다.
Partition key = accountId + entityType + localEntityId
같은 partition = 순서 보존
다른 partition = 제한된 병렬 처리
queue compaction을 한다면 사용자 의도와 audit 요구를 잃지 않는 작업만 합친다. 결제 두 건을 마지막 값 하나로 합치면 안 된다.
낙관적 UI와 서버 결과를 합치기
로컬 entity는 server ID를 받기 전에도 화면에 보여야 한다.
final class Entry {
const Entry({
required this.localId,
required this.value,
required this.syncState,
this.serverId,
this.syncError,
});
final String localId;
final String? serverId;
final int value;
final EntrySyncState syncState;
final String? syncError;
}
server response에 clientEntryId를 포함하면 올바른 local row와 합칠 수 있다.
UPDATE entries
SET
server_id = :server_id,
sync_state = 'synced',
sync_error = NULL
WHERE local_id = :client_entry_id;
UPDATE offline_operations
SET
state = 'done',
completed_at = :now,
lease_owner = NULL,
lease_expires_at = NULL
WHERE operation_id = :operation_id;
이 두 update도 local transaction으로 묶는다. response를 받은 뒤 entity만 synced로 바꾸고 operation row 갱신 전에 종료되더라도 재실행이 안전해야 한다. 서버가 같은 response를 replay하므로 reconciliation을 다시 수행할 수 있다.
Logout과 계정 전환에서 큐 격리하기
계정 A의 pending operation을 계정 B token으로 보내면 데이터 경계가 깨진다. 모든 queue row에 account scope를 넣고 worker가 현재 session과 비교한다.
if (operation.accountId != session.accountId) {
throw const SyncFailure.accountMismatch();
}
logout 정책을 정한다.
- pending 작업이 있으면 사용자에게 알리고 동기화를 기다림
- 암호화된 queue를 계정별로 보존하고 다음 로그인 때 재개
- 민감 정책상 local queue를 삭제
- server revoke와 별개로 worker를 즉시 중단
어느 정책이든 이전 account operation이 새 account 화면에 나타나거나 전송되지 않게 한다. queue payload에 credential을 저장하지 않고 전송 시점의 현재 session을 사용한다.
테스트해야 할 실패 시점
정상 요청 성공 test보다 어느 줄에서 process가 종료돼도 복구되는지 확인하는 test가 중요하다.
| 실패 주입 지점 | 기대 결과 |
|---|---|
| local entity insert 뒤 queue insert 전 | 같은 transaction rollback |
sending 변경 직후 종료 |
lease 만료 후 재시도 |
| 서버 domain commit 뒤 응답 유실 | 같은 operation replay |
| 서버 reservation 중 worker 종료 | processing 복구 정책 |
| response 수신 뒤 local reconciliation 전 종료 | replay response로 다시 합침 |
| 같은 key·다른 payload | conflict |
| 동일 operation 동시 두 요청 | domain effect 한 번 |
| 400 payload | permanent failed |
| 429 응답 | retry hint 이후 실행 |
| logout 중 worker 실행 | account mismatch로 중단 |
Property test로 같은 operation을 임의 횟수와 순서로 보내도 resource가 하나인지 확인할 수 있다.
it("creates one entry for repeated delivery", async () => {
const operationId = randomOperationId();
const request = createEntryFixture();
const responses = await Promise.all(
Array.from({ length: 5 }, () =>
api.createEntry(request, operationId),
),
);
expect(await entries.countByClientId(
request.clientEntryId,
)).toBe(1);
expect(uniqueBodies(responses)).toHaveLength(1);
});
운영 지표와 정리 정책
queue가 있다는 사실만으로 동기화가 건강한지 알 수 없다.
offline_queue_depth{state=pending}
offline_queue_oldest_age_seconds
offline_attempts_total{result=timeout}
idempotency_replays_total{operation=create_entry}
idempotency_conflicts_total
offline_permanent_failures_total{code=validation}
특히 가장 오래된 pending 항목의 age가 중요하다. queue depth가 작아도 한 작업이 며칠간 막혀 있을 수 있다.
정리 정책:
- local done operation은 reconciliation과 진단 기간 후 삭제
- server idempotency record는 client 최대 재시도 기간보다 길게 보관
- 만료 후 같은 key가 다시 오면 새 작업으로 볼지 expired 오류를 낼지 결정
- response body가 크거나 민감하면 필요한 최소 결과만 저장
- permanent failed payload의 사용자 수정·삭제 경로 제공
server record를 너무 빨리 지우면 오래 offline이던 앱의 재전송이 새 작업으로 실행될 수 있다.
구현 체크리스트
마무리
오프라인 큐는 network가 돌아왔을 때 요청 목록을 한 번 보내는 기능이 아니다. 요청과 응답 사이 어느 지점에서든 앱과 연결이 끊길 수 있다는 사실을 상태로 관리하는 시스템이다.
Client는 exactly once 전달을 만들려고 하지 않는다. 사용자 의도마다 operation ID를 고정하고, local entity와 queue를 한 transaction으로 저장한다. worker는 lease로 전송 소유권을 얻되 중복 실행 가능성을 인정한다. retryable 오류만 backoff해 다시 보낸다.
서버는 actor와 operation ID의 unique record를 먼저 확보하고, 같은 payload의 재전송에는 이미 저장한 결과를 반환한다. 같은 key에 다른 payload가 오면 조용히 성공시키지 않고 계약 위반으로 드러낸다.
이렇게 local queue의 복구 가능성과 server idempotency가 함께 있을 때 응답 유실, 앱 종료, 중복 worker가 모두 정상적인 재전송 조건이 된다. 멱등성은 retry를 없애는 기능이 아니라 retry를 안전하게 사용할 수 있게 만드는 전제다.
관련 노트
- 재시도 가능한 API에 Idempotency-Key 적용하기
- 배치 작업을 재실행 가능하게 만드는 체크포인트
- 재시도에 지수 백오프와 지터가 필요한 이유
- 앱의 네트워크 상태를 신뢰하면 안 되는 이유
- Outbox Pattern으로 DB와 이벤트 발행 일치시키기
- React Native WebView 오프라인 런타임 설계